﻿using System;
using System.Linq;
#if NETFX_CORE
using Windows.Foundation;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using System.Windows.Input;
using Windows.UI.Xaml.Input;
#else
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
#endif

namespace FreshMeat.Xaml
{
   public class DragBehavior : AttachedBehavior<DragBehavior, FrameworkElement>
   {
      /// <summary>
      /// Canvas to be used for drag visual effects.
      /// This attached property MUST be set in order to activate the drag behavior.
      /// </summary>
      public static readonly DependencyProperty LayerProperty =
         DependencyProperty.RegisterAttached("Layer", typeof(Canvas), typeof(DragBehavior), new PropertyMetadata(null, (d, e) => GetInstance(d)));

      public static Canvas GetLayer(FrameworkElement element)
      {
         return (Canvas)element.GetValue(LayerProperty);
      }

      public static void SetLayer(FrameworkElement element, Canvas value)
      {
         element.SetValue(LayerProperty, value);
      }

      /// <summary>
      /// Optional name of the parent container (all drag operations must start within/below this container in the visual tree).
      /// If this is not set then the associated object is used as the drag container.
      /// </summary>
      public static readonly DependencyProperty ContainerNameProperty =
         DependencyProperty.RegisterAttached("ContainerName", typeof(string), typeof(DragBehavior), new PropertyMetadata(null));

      public static string GetContainerName(FrameworkElement element)
      {
         return (string)element.GetValue(ContainerNameProperty);
      }

      public static void SetContainerName(FrameworkElement element, string value)
      {
         element.SetValue(ContainerNameProperty, value);
      }

      /// <summary>
      /// The type name of the element that can be dragged.
      /// This can be set on any of the following: (1) any ItemsControl from which elements
      /// can be dragged, (2) the object designated by <see cref="ContainerNameProperty"/>,
      /// or (3) the same object which set <see cref="LayerProperty"/>. If specified in
      /// multiple places, the order of precedence is the order listed above.
      /// </summary>
      public static readonly DependencyProperty PayloadTypeProperty =
         DependencyProperty.RegisterAttached("PayloadType", typeof(string), typeof(DragBehavior), new PropertyMetadata(null));

      public static string GetPayloadType(FrameworkElement element)
      {
         return (string)element.GetValue(PayloadTypeProperty);
      }

      public static void SetPayloadType(FrameworkElement element, string value)
      {
         element.SetValue(PayloadTypeProperty, value);
      }

      /// <summary>
      /// Command which removes an item from its source container.
      /// This must be set on any ItemsControl from which items will be dragged.
      /// </summary>
      public static readonly DependencyProperty RemoveCommandProperty =
         DependencyProperty.RegisterAttached("RemoveCommand", typeof(ICommand), typeof(DragBehavior), new PropertyMetadata(null));

      public static ICommand GetRemoveCommand(FrameworkElement element)
      {
         return (ICommand)element.GetValue(RemoveCommandProperty);
      }

      public static void SetRemoveCommand(FrameworkElement element, ICommand value)
      {
         element.SetValue(RemoveCommandProperty, value);
      }

      /// <summary>
      /// Command which adds an item to a target container.
      /// This must be set on any ItemsControl on which items will be dropped.
      /// </summary>
      public static readonly DependencyProperty AddCommandProperty =
         DependencyProperty.RegisterAttached("AddCommand", typeof(ICommand), typeof(DragBehavior), new PropertyMetadata(null));

      public static ICommand GetAddCommand(FrameworkElement element)
      {
         return (ICommand)element.GetValue(AddCommandProperty);
      }

      public static void SetAddCommand(FrameworkElement element, ICommand value)
      {
         element.SetValue(AddCommandProperty, value);
      }

      /// <summary>
      /// Style for the DragItem, which wraps the element being dragged.
      /// This can be set on any of the following: (1) any ItemsControl from which elements
      /// can be dragged, (2) the object designated by <see cref="ContainerNameProperty"/>,
      /// or (3) the same object which set <see cref="LayerProperty"/>. If specified in
      /// multiple places, the order of precedence is the order listed above.
      /// </summary>
      public static readonly DependencyProperty PayloadStyleProperty =
         DependencyProperty.RegisterAttached("PayloadStyle", typeof(Style), typeof(DragBehavior), new PropertyMetadata(null));

      public static Style GetPayloadStyle(FrameworkElement element)
      {
         return (Style)element.GetValue(PayloadStyleProperty);
      }

      public static void SetPayloadStyle(FrameworkElement element, Style value)
      {
         element.SetValue(PayloadStyleProperty, value);
      }

      /// <summary>
      /// Indicates whether the behavior is currently performing a drag operation.
      /// This must be set by the same object which set the LayerProperty.
      /// It is intended to be bound to a model using "Mode=TwoWay" so that the
      /// model can be informed as to the current drag state.  The model should
      /// not attempt to change the value of this property (it is only "TwoWay"
      /// because not all XAML flavors support "OneWayToSource", which is the
      /// real intention.
      /// </summary>
      public static readonly DependencyProperty IsDraggingProperty =
         DependencyProperty.RegisterAttached("IsDragging", typeof(bool), typeof(DragBehavior), new PropertyMetadata(false));

      public static bool GetIsDragging(FrameworkElement element)
      {
         return (bool)element.GetValue(IsDraggingProperty);
      }

      public static void SetIsDragging(FrameworkElement element, bool value)
      {
         // DO NOTHING when this is called -- this property is intended to be "read-only" but that's
         // an odd concept for an attached property used in XAML, so this is a regular attached property
         // which happens not to do anything when written to.
      }

      const double DragTriggerDistance = 2.0;
      const double DragTriggerDistanceSquared = DragTriggerDistance * DragTriggerDistance;

      bool isPossibleStart;
      Point mouseDownPoint;
      Payload payload;

      protected override void OnAttached()
      {
         var associatedObject = TryGetAssociatedObject();

         // The visual tree is not always complete at this point
         // so we wait until later in the lifecycle to actually attach.
         associatedObject.LayoutUpdated += AssociatedObjectReady;
      }

#if NETFX_CORE
      void AssociatedObjectReady(object sender, object eventArgs)
#else
      void AssociatedObjectReady(object sender, EventArgs eventArgs)
#endif
      {
         var associatedObject = TryGetAssociatedObject();
         if (associatedObject == null)
            return;

         associatedObject.LayoutUpdated -= AssociatedObjectReady;

         var dragContainer = FindDragContainer(associatedObject);
         if (dragContainer == null)
            return;

#if NETFX_CORE
         dragContainer.PointerPressed += MouseLeftButtonDown;
         dragContainer.PointerReleased += MouseLeftButtonUp;
         dragContainer.PointerMoved += MouseMove;
#else
         dragContainer.MouseLeftButtonDown += MouseLeftButtonDown;
         dragContainer.MouseLeftButtonUp += MouseLeftButtonUp;
         dragContainer.MouseMove += MouseMove;
#endif
      }

      protected override void OnDetaching()
      {
         var associatedObject = TryGetAssociatedObject();
         if (associatedObject == null)
            return;

         var dragContainer = FindDragContainer(associatedObject);
         if (dragContainer == null)
            return;

#if NETFX_CORE
         dragContainer.PointerPressed -= MouseLeftButtonDown;
         dragContainer.PointerReleased -= MouseLeftButtonUp;
         dragContainer.PointerMoved -= MouseMove;
#else
         dragContainer.MouseLeftButtonDown -= MouseLeftButtonDown;
         dragContainer.MouseLeftButtonUp -= MouseLeftButtonUp;
         dragContainer.MouseMove -= MouseMove;
#endif
      }

      /// <summary>
      /// Handles the left mouse button going down, which is the first step towards starting a drag.
      /// </summary>
#if NETFX_CORE
      void MouseLeftButtonDown(object sender, PointerRoutedEventArgs e)
#else
      void MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
#endif
      {
         if (e.Handled)
            return;

         // Early-out avoids unnecessary call to relatively expensive TryGetContext
         if (isPossibleStart || payload != null)
            return;

         var context = TryGetContext();
         if (context == null)
            return;

         e.Handled = true;

         mouseDownPoint = GetPointRelativeTo(e, context.DragContainer);
         isPossibleStart = true;
      }

      /// <summary>
      /// Handles the mouse move, which is either the second step towards starting a drag or
      /// is a continuation of a drag operation.
      /// </summary>
#if NETFX_CORE
      void MouseMove(object sender, PointerRoutedEventArgs e)
#else
      void MouseMove(object sender, MouseEventArgs e)
#endif
      {
         // Early-out avoids unnecessary call to relatively expensive TryGetContext
         if (!isPossibleStart && payload == null)
            return;

         var context = TryGetContext();
         if (context == null)
            return;

         if (isPossibleStart)
         {
            // Mouse must move at least the trigger distance from the original point
            // where it went down in order to start a drag operation.
            var point = GetPointRelativeTo(e, context.DragContainer);
            var a = point.X - mouseDownPoint.X;
            var b = point.Y - mouseDownPoint.Y;
            if (a * a + b * b > DragTriggerDistanceSquared)
            {
               // It's definitely a drag in the technical sense of what the mouse did
               // but now we've got to see if it's really anything useful based on
               // what they clicked on and might be attempting to drag.
               // In any case, it's no longer a "possible start".
               isPossibleStart = false;

               // Look for a payload (which is what will be dragged) and its source container.
               FrameworkElement element;
               FrameworkElement source;
               if (FindDraggable(context, mouseDownPoint, out element, out source))
               {
                  // If mouse can be captured then start dragging.
#if NETFX_CORE
                  if (context.DragContainer.CapturePointer(e.Pointer))
#else
                  if (context.DragContainer.CaptureMouse())
#endif
                  {
#if !NETFX_CORE
                     context.DragContainer.Cursor = Cursors.Hand;
#endif
                     payload = new Payload(element, source, GetPointRelativeTo(e, element), context.Layer, FindPayloadStyle(context, source));
                     context.AssociatedObject.SetValue(IsDraggingProperty, true);
                  }
               }
            }
         }
         // Don't make this an "else" clause -- if the drag was started above then this must be done immediately.
         // This also handles drags that were already in progress.
         if (payload != null)
         {
            FrameworkElement target;
            FrameworkElement subTarget;
            if (FindTarget(context, GetPointRelativeTo(e, context.DragContainer), out target, out subTarget))
               payload.SetFeedbackCanMoveTo(target);
            else
               payload.SetFeedback(false, null);

            payload.MoveVisual(GetPointRelativeTo(e, context.Layer));
         }
      }

      /// <summary>
      /// Handles the left mouse button being released, which either aborts a drag
      /// before it ever gets going or ends an actual drag operation.
      /// </summary>
#if NETFX_CORE
      void MouseLeftButtonUp(object sender, PointerRoutedEventArgs e)
#else
      void MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
#endif
      {
         if (e.Handled)
            return;

         // Early-out avoids unnecessary call to relatively expensive TryGetContext
         if (!isPossibleStart && payload == null)
            return;

         var context = TryGetContext();
         if (context == null)
            return;

         e.Handled = true;

         if (payload != null)
         {
            FrameworkElement target;
            FrameworkElement subTarget;
            if (FindTarget(context, GetPointRelativeTo(e, context.DragContainer), out target, out subTarget))
               payload.TryMoveTo(target, subTarget);
         }

         Reset(context.AssociatedObject, context.DragContainer, context.Layer);
      }

#if NETFX_CORE
      static Point GetPointRelativeTo(PointerRoutedEventArgs e, UIElement element)
      {
         return e.GetCurrentPoint(element).Position;
      }
#else
      static Point GetPointRelativeTo(MouseEventArgs e, UIElement element)
      {
         return e.GetPosition(element);
      }
#endif

      /// <summary>
      /// Tries to get the context within which to perform the drag.
      /// If one or more values are not available then any existing operation is automatically reset.
      /// </summary>
      /// <returns>Context or null if drag cannot be performed.</returns>
      Context TryGetContext()
      {
         FrameworkElement dragContainer = null;

         var associatedObject = TryGetAssociatedObject();
         if (associatedObject != null)
         {
            dragContainer = FindDragContainer(associatedObject);
            if (dragContainer != null)
            {
               var layer = GetLayer(associatedObject);
               if (layer != null)
               {
                  return new Context(associatedObject, dragContainer, layer);
               }
            }
         }

         Reset(associatedObject, dragContainer, null);

         return null;
      }

      /// <summary>
      /// Resets everything back to a non-dragging state.
      /// Safe to call at any point.
      /// </summary>
      /// <param name="associatedObject"> </param>
      /// <param name="dragContainer"> </param>
      /// <param name="layer">Layer if known, otherwise null.</param>
      void Reset(FrameworkElement associatedObject, FrameworkElement dragContainer, Canvas layer)
      {
         if (dragContainer != null)
         {
#if NETFX_CORE
            dragContainer.ReleasePointerCaptures();
#else
            dragContainer.ReleaseMouseCapture();
            dragContainer.Cursor = null;
#endif
         }

         if (payload != null)
         {
            payload.Clear(layer);
            payload = null;
         }

         isPossibleStart = false;

         if (associatedObject != null)
            associatedObject.SetValue(IsDraggingProperty, false);
      }

      static FrameworkElement FindDragContainer(FrameworkElement associatedObject)
      {
         // If a specific container was not specified then use the associated object.
         var containerName = GetContainerName(associatedObject);
         if (containerName == null)
            return associatedObject;

         if (string.IsNullOrEmpty(containerName))
            return null;

         var root = VisualHelper.GetRoot(associatedObject);
         var container = VisualTreeEnumerator.EnumerateDepthFirst(root).OfType<FrameworkElement>().FirstOrDefault(e => string.Compare(e.Name, containerName, StringComparison.OrdinalIgnoreCase) == 0);
         return container;
      }

      static Style FindPayloadStyle(Context context, FrameworkElement source)
      {
         var style = GetPayloadStyle(source);
         if (style == null)
         {
            style = GetPayloadStyle(context.DragContainer);
            if (style == null && !ReferenceEquals(context.DragContainer, context.AssociatedObject))
               style = GetPayloadStyle(context.AssociatedObject);
         }

         return style;
      }

      static string FindPayloadType(Context context, FrameworkElement source)
      {
         var typeName = GetPayloadType(source);
         if (typeName == null)
         {
            typeName = GetPayloadType(context.DragContainer);
            if (typeName == null && !ReferenceEquals(context.DragContainer, context.AssociatedObject))
               typeName = GetPayloadType(context.AssociatedObject);
         }

         return typeName;
      }

      /// <summary>
      /// Looks for an element suitable for dragging at the specified position.
      /// </summary>
      static bool FindDraggable(Context context, Point point, out FrameworkElement element, out FrameworkElement source)
      {
         var hits = VisualHelper.FindIntersectingElements(context.DragContainer, point);
         foreach (var hit in hits)
         {
            // Go up the tree from the hit looking for a supported source with a RemoveCommand.
            // Currently, only ItemsControl is supported.
            source = VisualTreeEnumerator.EnumerateUp(hit).OfType<ItemsControl>().FirstOrDefault(i => GetRemoveCommand(i) != null);
            if (source != null)
            {
               // Now get the payload type name, which can optionally be defined by the source
               // which is why we wait until here to get it.
               var typeName = FindPayloadType(context, source);
               if (typeName != null)
               {
                  // Now go up the tree from the hit looking for an element of the specified type.
                  element = VisualTreeEnumerator.EnumerateUp(hit, context.DragContainer).FirstOrDefault(i => string.Compare(i.GetType().Name, typeName, StringComparison.Ordinal) == 0) as FrameworkElement;
                  if (element != null)
                  {
                     // Last but not least, use the command to ask the source if it's ok to drag this item.
                     // Since the target is unknown, the command will typically allow the drag.
                     var command = GetRemoveCommand(source);
                     var description = new DragDescription(source.DataContext, element.DataContext, null);
                     if (command.CanExecute(description))
                     {
                        return true;
                     }
                  }
               }
            }
         }

         element = null;
         source = null;
         return false;
      }

      /// <summary>
      /// Looks for a suitable target for the payload at the specified position.
      /// </summary>
      bool FindTarget(Context context, Point point, out FrameworkElement target, out FrameworkElement subTarget)
      {
         var hits = VisualHelper.FindIntersectingElements(context.DragContainer, point);
         foreach (var hit in hits)
         {
            // Go up the tree from the hit looking for a supported target with an AddCommand.
            // Currently, only ItemsControl is supported.
            target = VisualTreeEnumerator.EnumerateUp(hit).OfType<ItemsControl>().FirstOrDefault(i => GetAddCommand(i) != null);
            if (target != null)
            {
               // Again go up the tree from the hit, this time looking for the sub-target.
               // Currently, only an ItemsControl's items host panel is supported.
               subTarget = VisualTreeEnumerator.EnumerateUp(hit, target).OfType<Panel>().FirstOrDefault(p => p.IsItemsHost);
               if (subTarget != null)
               {
                  return true;
               }
            }
         }

         target = null;
         subTarget = null;
         return false;
      }

      class Context
      {
         public FrameworkElement AssociatedObject { get; private set; }
         public FrameworkElement DragContainer { get; private set; }
         public Canvas Layer { get; private set; }

         public Context(FrameworkElement associatedObject, FrameworkElement dragContainer, Canvas layer)
         {
            AssociatedObject = associatedObject;
            DragContainer = dragContainer;
            Layer = layer;
         }
      }

      class Payload
      {
         FrameworkElement source;
         FrameworkElement element;
         readonly double originalElementOpacity;
         Point offset;
         DragItem item;

         /// <summary>
         /// Creates instance.
         /// </summary>
         /// <param name="element">The element being dragged around.</param>
         /// <param name="source">The container within which the item is currently located.</param>
         /// <param name="relativePosition">The mouse position relative to the element's upper left corner.</param>
         /// <param name="layer">The canvas used to perform drag-related visual effects.</param>
         /// <param name="style">Data template for element or null if none.</param>
         public Payload(FrameworkElement element, FrameworkElement source, Point relativePosition, Canvas layer, Style style)
         {
            this.element = element;
            this.source = source;

            // Remember the offset from the upper left corner to the mouse position
            // so we can maintain that offset as the element is dragged around.
            offset = new Point(-relativePosition.X, -relativePosition.Y);

            originalElementOpacity = element.Opacity;

            // Create a drag item whose content is an image (snapshot) of the element being dragged.
            var image = VisualHelper.GenerateVisual(element);
            item = new DragItem
            {
               Content = image, 
#if !NETFX_CORE
               Cursor = Cursors.Hand
#endif
            };
            if (style != null)
               item.Style = style;

            layer.Children.Add(item);
         }

         public void MoveVisual(Point point)
         {
            Canvas.SetLeft(item, point.X + offset.X);
            Canvas.SetTop(item, point.Y + offset.Y);

            if (element.Opacity > 0)
               element.Opacity = 0;
         }

         public void Clear(Canvas layer)
         {
            if (layer != null)
               layer.Children.Clear();

            if (element != null)
            {
               element.Opacity = originalElementOpacity;
               element = null;
            }

            source = null;
            item = null;
         }

         // ReSharper disable UnusedMethodReturnValue.Local
         public bool SetFeedbackCanMoveTo(FrameworkElement target)
         // ReSharper restore UnusedMethodReturnValue.Local
         {
            return InternalMove(target, null, true);
         }

         public void TryMoveTo(FrameworkElement target, FrameworkElement subTarget)
         {
            InternalMove(target, subTarget, false);
         }

         bool InternalMove(FrameworkElement target, FrameworkElement subTarget, bool justTry)
         {
            var description = new DragDescription(source.DataContext, element.DataContext, target.DataContext);

            var removeCommand = GetRemoveCommand(source);
            if (!removeCommand.CanExecute(description))
            {
               SetFeedback(false, description.Feedback);
               return false;
            }

            var addCommand = GetAddCommand(target);
            if (!addCommand.CanExecute(description))
            {
               SetFeedback(false, description.Feedback);
               return false;
            }

            SetFeedback(true, description.Feedback);

            if (justTry)
               return true;

            // It's not clear yet how to handle other items host panels where an item's
            // position in the list is what determines its position in the UI.  It seems
            // like there are two options:
            // (1) Instead of having the commands perform the remove and add we would instead
            // do those here, taking into account each panel's particularities to ensure 
            // the item ends up in the right position.
            // (2) Add an additional property to the DragDescription that tells the add command
            // the position in the list where the item should be inserted.  In the case of the
            // grid, the position wouldn't matter and could always be the end.  I like this
            // slightly better because it keeps the manipulation of the collections in the
            // hands of the command/model.

            var targetPosition = GetRelativePosition(subTarget);

            // Update the payload's position on the grid BEFORE removing the item from the source.
            // This ensures that when the element's Grid.Row and Grid.Column attached properties are
            // updated, the new values will flow back through the two-way bindings to the binding
            // sources (presumably to Row and Column properties in the item).  After this, we
            // remove the item from the source and add it to the destination, where the item
            // will show up in the intended position since it already has the correct Row/Column values.
            var grid = subTarget as Grid;
            if (grid != null)
               DragGridHelper.PositionOnGrid(grid, targetPosition, element);

            // Remove the item from the source and add it to the target.
            removeCommand.Execute(description);
            addCommand.Execute(description);

            return true;
         }

         public void SetFeedback(bool isAllowed, string feedback)
         {
            item.Update(isAllowed, feedback);
         }

         Point GetRelativePosition(FrameworkElement target)
         {
            var transform = item.TransformToVisual(target);
#if NETFX_CORE
            var targetPosition = transform.TransformPoint(
#else
            var targetPosition = transform.Transform(
#endif
               new Point(item.ActualWidth / 2, item.ActualHeight / 2));
            return targetPosition;
         }
      }
   }
}